Цели:

  1. Разобраться, как ведут себя пользователи мобильного приложения;
  2. Узнать, как пользователи доходят до покупки.

Задачи:

  1. Изучить и проверить данные;
  2. Изучить воронку продаж;
  3. Исследовать результаты A/A/B-эксперимента.

Анализ поведения пользователей мобильного приложения в стартапе по продаже продуктов питания

  • 1  Загрузка файл с данными и изучение общей информации
  • 2  Предподготовка данных
    • 2.1  Замена названий столбцов
    • 2.2  Работа с дубликатами
    • 2.3  Изменение типов данных
    • 2.4  Добавление необходимых столбцов
  • 3  Изучение и проверка данных
    • 3.1  Расчет количества событий, пользователей, среднего числа событий на пользователя и определение временного отрезка
    • 3.2  Определение оптимального по наполнению данными временного периода
    • 3.3  Проверка наполненности трех экспериментальных групп
  • 4  Изучение воронки событий
    • 4.1  Расчет количества и доли пользователей по каждому из событий и повторяемость самих событий
    • 4.2  Расчет коэффициента оттока
  • 5  Изучение результатов эксперимента
    • 5.1  А/A-тестирование
    • 5.2  A/B и A/A/B-тестирования
  • 6  Общие выводы
In [1]:
# загружаю библиотеки
import pandas as pd
import numpy as np
import math as mth
import datetime as dt
import scipy.stats as st
from functools import reduce
import matplotlib.pyplot as plt
%matplotlib inline
import seaborn as sns
from plotly import graph_objects as go
import warnings
warnings.filterwarnings('ignore')
In [2]:
# создаю функцию для просмотра датасета
def first_view(x):
    print('-' * 50, '\n', 'Исходный датафрейм:', '\n', '-'*50)
    display(x.head())
    print('-' * 50, '\n', 'Общая информация о датафрейме:', '\n', '-'*50)
    display(x.info())
    print('-' * 50, '\n', 'Количество пустых значений в датафрейме:', '\n', '-'*50)
    display(x.isna().sum())
    print('-' * 50,'\n','Количество явных дубликатов в датафрейме:','\n','-'*50)
    display(x.duplicated().sum())
    print('-' * 50,'\n','Названия столбцов:','\n','-'*50)
    display(x.columns)
In [3]:
# создаю функцию для расчёта статистически значимой разницы
# между долями двух генеральных совокупностей
def stat_value(first_list, second_list, m, alpha):
    alpha_holm = []
    # получаем номер текущего теста и считаем коррекцию 
    for i in range(m):
        alpha_holm += [alpha / (m - i)]
    # основной цикл функции,
    # который вычисляет p-value и проверяет нулевую гипотезу 
    for x in range(0, len(first_list)-1):
        successes = np.array([first_list[x+1], second_list[x+1]])
        trials = np.array([first_list[x], second_list[x]])
        # пропорция успехов в первой группе
        p1 = successes[0]/trials[0]
        # пропорция успехов во второй группе
        p2 = successes[1]/trials[1]
        # пропорция успехов в комбинированном датасете:
        p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
        # разница пропорций между группами
        difference = p1 - p2
        # считаем статистику в ст.отклонениях стандартного нормального распределения
        z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
        # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
        distr = st.norm(0, 1)
        # расчёт значения статистической разницы между группами
        p_value = 1 - distr.cdf(z_value)
        # вывод полученных результатов
        print('Событие:', list(exp_group.index)[x])
        print('Значение p-value: {0:.5f}'.format(p_value))
        # сравнение полученного p-value с уровнем статистической значимости
        test_result = alpha_holm > p_value
        print(
            'Доля отвергнутых нулевых гипотез {:.1%} для {} тестов с коррекцией Холма\n'
            .format(test_result.mean(), len(test_result))
        ) 

Загрузка файл с данными и изучение общей информации¶

Применю функцию с набором методов для просмотра сводной информации.

In [4]:
# загружаю датасет
data = pd.read_csv('logs_exp.csv', sep='\t')
In [5]:
first_view(data)
-------------------------------------------------- 
 Исходный датафрейм: 
 --------------------------------------------------
EventName DeviceIDHash EventTimestamp ExpId
0 MainScreenAppear 4575588528974610257 1564029816 246
1 MainScreenAppear 7416695313311560658 1564053102 246
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248
3 CartScreenAppear 3518123091307005509 1564054127 248
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248
-------------------------------------------------- 
 Общая информация о датафрейме: 
 --------------------------------------------------
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 244126 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column          Non-Null Count   Dtype 
---  ------          --------------   ----- 
 0   EventName       244126 non-null  object
 1   DeviceIDHash    244126 non-null  int64 
 2   EventTimestamp  244126 non-null  int64 
 3   ExpId           244126 non-null  int64 
dtypes: int64(3), object(1)
memory usage: 7.5+ MB
None
-------------------------------------------------- 
 Количество пустых значений в датафрейме: 
 --------------------------------------------------
EventName         0
DeviceIDHash      0
EventTimestamp    0
ExpId             0
dtype: int64
-------------------------------------------------- 
 Количество явных дубликатов в датафрейме: 
 --------------------------------------------------
413
-------------------------------------------------- 
 Названия столбцов: 
 --------------------------------------------------
Index(['EventName', 'DeviceIDHash', 'EventTimestamp', 'ExpId'], dtype='object')

Вывод: \ Датасет состооит из 4 столбцов и 244 126 строк. Столбцы содержат следующие данные:

  • EventName — название события;
  • DeviceIDHash — уникальный идентификатор пользователя;
  • EventTimestamp — время события;
  • ExpId — номер эксперимента: 246 и 247 — контрольные группы, а 248 — экспериментальная.

Пропуски не выявлены. Количество явных дубликатов - 413. Названия столбцов требуют преобразования. Столбец EventTimestamp нужно преобразовать к типу данных datetime64[ns]. Данные в столбцах DeviceIDHash и ExpId требуется для удобства дальнейшего исследования преобразовать в object.

Предподготовка данных¶

Замена названий столбцов¶

In [6]:
data.columns = ['event', 'user_id', 'event_timestamp', 'group']
data.columns
Out[6]:
Index(['event', 'user_id', 'event_timestamp', 'group'], dtype='object')

Вывод: \ Для удобства работы с файлом заменил названия столбцов на более ёмкие. Так будет удобнее работать с данными на этапе исследования.

Работа с дубликатами¶

In [7]:
print('Процент дубликатов в рамках всего датафрейма: {:.3%}'
      .format(data.duplicated().sum() / data['user_id'].count()))
Процент дубликатов в рамках всего датафрейма: 0.169%
In [8]:
# формирую таблицу с количеством пропусков по столбцам
data[data.duplicated()].groupby('event')['user_id'].count().sort_values(ascending=False).to_frame()
Out[8]:
user_id
event
PaymentScreenSuccessful 195
MainScreenAppear 104
CartScreenAppear 63
Tutorial 34
OffersScreenAppear 17
In [9]:
data.drop_duplicates(inplace=True)
print('Количество дубликатов после очистки датафрейма:', data.duplicated().sum())
Количество дубликатов после очистки датафрейма: 0
In [10]:
print('Уникальные события:')
list(data['event'].unique())
Уникальные события:
Out[10]:
['MainScreenAppear',
 'PaymentScreenSuccessful',
 'CartScreenAppear',
 'OffersScreenAppear',
 'Tutorial']
In [11]:
print('Уникальные группы:')
list(data['group'].unique())
Уникальные группы:
Out[11]:
[246, 248, 247]
In [12]:
data_dubl_user = reduce(
    np.intersect1d, (data[data['group'] == 246]['user_id'],
                     data[data['group'] == 247]['user_id'],
                     data[data['group'] == 248]['user_id'])
)
print('Количество повторяющихся пользователей в трех группах:', len(data_dubl_user))
Количество повторяющихся пользователей в трех группах: 0

Вывод: \ Проверил, какой процент явных дубликатов в датасете от общего числа записей и в каком количественном выражении по типу события, а затем все лишние строки удалил. Проверка уникальных значений среди названий событий и групп не выявили неявных дубликатов. Далее посмотрел датасат на повторяющихся пользователей в исследуемых группах, они не были обнаружены.

Изменение типов данных¶

In [13]:
data[['user_id', 'group']] = data[['user_id', 'group']].astype('str')
data.info()
<class 'pandas.core.frame.DataFrame'>
Index: 243713 entries, 0 to 244125
Data columns (total 4 columns):
 #   Column           Non-Null Count   Dtype 
---  ------           --------------   ----- 
 0   event            243713 non-null  object
 1   user_id          243713 non-null  object
 2   event_timestamp  243713 non-null  int64 
 3   group            243713 non-null  object
dtypes: int64(1), object(3)
memory usage: 9.3+ MB

Вывод: \ На этапе загрузки датафрейма в 2 столбцах возникла потребность в изменении типа данных с int64 на object. Соответствующую корректировку внес. Столбец event_timestamp содержит данные в виде UNIX timestamp его оставил без изменений, а на следующем этапе добавлю столбцы с нужными форматами дат и времени. Повторно проверил, что корректировка типов данных сработала.

Добавление необходимых столбцов¶

In [14]:
# добавляю в датасет столбцы
data['event_dt'] = pd.to_datetime(data['event_timestamp'], unit='s')
data['dt'] = pd.to_datetime(data['event_timestamp'], unit='s').dt.date
data.head()
Out[14]:
event user_id event_timestamp group event_dt dt
0 MainScreenAppear 4575588528974610257 1564029816 246 2019-07-25 04:43:36 2019-07-25
1 MainScreenAppear 7416695313311560658 1564053102 246 2019-07-25 11:11:42 2019-07-25
2 PaymentScreenSuccessful 3518123091307005509 1564054127 248 2019-07-25 11:28:47 2019-07-25
3 CartScreenAppear 3518123091307005509 1564054127 248 2019-07-25 11:28:47 2019-07-25
4 PaymentScreenSuccessful 6217807653094995999 1564055322 248 2019-07-25 11:48:42 2019-07-25

Вывод: \ В датафрейм добавил столбцы с датой и временем и просто с датой.

Изучение и проверка данных¶

Расчет количества событий, пользователей, среднего числа событий на пользователя и определение временного отрезка¶

In [15]:
print('Общее количество событий в логе: {}\n'
      .format(data['event'].count())
     )
print('Общее количество пользоватлей в логе: {}\n'
      .format(data['user_id'].nunique())
     )
print('Среднее количество событий на пользователя: {:.2f}\n'
      .format((data['event'].count() / data['user_id'].nunique()))
     )
print('Минимальная дата события:', data['event_dt'].min())
print('Максимальная дата события:', data['event_dt'].max())
Общее количество событий в логе: 243713

Общее количество пользоватлей в логе: 7551

Среднее количество событий на пользователя: 32.28

Минимальная дата события: 2019-07-25 04:43:36
Максимальная дата события: 2019-08-07 21:15:17

Определение оптимального по наполнению данными временного периода¶

In [16]:
# строю визуализацию
plt.figure(figsize=(15, 6))
sns.histplot(data=data, x='dt', hue='group', multiple='dodge', palette='Set1', shrink=.9,)
plt.grid(axis='y')
plt.xticks(rotation=45)
# обозначаю границу отсечения
plt.axvline(x=dt.datetime(2019, 7, 31, 12), color='grey', linestyle='--')
# указываю названия графика и осей
plt.title('Количество событий в зависимости от времени в разрезе групп', size=14)
plt.xlabel('Дата')
plt.ylabel('Количество событий')
plt.show()
In [17]:
print('Количество событий из прошлого: {}'
      .format(len(data.query('event_dt < "2019-08-01"'))))
print('Процент событий из прошлого: {:.2%}'
      .format(len(data.query('event_dt < "2019-08-01"')) / len(data)))
Количество событий из прошлого: 2826
Процент событий из прошлого: 1.16%
In [18]:
print('Количество пользователей из прошлого: {}'
      .format(data['user_id'].nunique() -\
              data.query('event_dt >= "2019-08-01"')['user_id'].nunique()))
print('Процент пользователей из прошлого: {:.2%}'
      .format((data['user_id'].nunique() -\
               data.query('event_dt >= "2019-08-01"')['user_id'].nunique()) /\
              data['user_id'].nunique()))
Количество пользователей из прошлого: 17
Процент пользователей из прошлого: 0.23%
In [19]:
# подготавливаю датасет для дальнейшего исследования
data_research = data.query('event_dt >= "2019-08-01"')

Вывод: \ Построил гистограмму, по которой очень четко видна разница в наполнении каждого дня по каждому событию. Все данные до 2019-08-01 брать для дальнейшего исследования не верно, так как это может «перекашивать данные». Именно с этой даты данные полные и логично отбросить более старые. Дополнительно проверил процент "отброшенных" событий - 2 826, что менее 1.5%, а количество пользователей - 17 или 0.23%. Такие потери допустимы.

Проверка наполненности трех экспериментальных групп¶

In [20]:
# формирую таблицу с данными по группам
data_research.groupby('group').agg(count_events = ('event', 'count'),
                                   users = ('user_id', 'nunique'))
Out[20]:
count_events users
group
246 79302 2484
247 77022 2513
248 84563 2537

Вывод: \ Все три группы состоят из почти равного числа пользователей и приближенного по количеству числа событий.

Изучение воронки событий¶

Расчет количества и доли пользователей по каждому из событий и повторяемость самих событий¶

In [21]:
# создаю сводную таблицу
users_count = (
    data_research.groupby('event')
    .agg(count_events = ('event', 'count'),
         users = ('user_id', 'nunique'))
)
users_count['users_part'] = (
    users_count['users'] / data_research['user_id']
    .nunique() * 100).round(2)
users_count.sort_values(
    by='count_events',
    ascending=False,
    inplace=True)
users_count
Out[21]:
count_events users users_part
event
MainScreenAppear 117328 7419 98.47
OffersScreenAppear 46333 4593 60.96
CartScreenAppear 42303 3734 49.56
PaymentScreenSuccessful 33918 3539 46.97
Tutorial 1005 840 11.15

Вывод: \ Провел расчет количества и доли пользователей по каждому из событий и повторяемость самих событий. В логах представлены 5 типов событий, самое частотное - MainScreenAppear в 2.5 раза превосходит следующее, по количеству пользователей в 1.6 раза. Самое редко встречающееся - Tutorial, это вполне закономерно, так как инструкции по работе с приложениями обычно не читают, юзеры ориентируются по факту пользования интерфейсом. Стоит заметить что доля пользователей в собитии MainScreenAppear составляет не 100%, что может говорить о переходе по рекламному баннеру или по ссылке через поисковый запрос.\ Можно предположить, что события происходят в следующем порядке:

  1. MainScreenAppear - главный экран;
  2. OffersScreenAppear - экран карточки товара;
  3. CartScreenAppear - корзина;
  4. PaymentScreenSuccessful - страница успешной оплаты;
  • Tutorial - инструкция по работе с приложением. Данное событие не возможно точно встроить в цепочку действий. Правильно будет не учитывать его при расчёте воронки.

Расчет коэффициента оттока¶

In [22]:
# добавляю столбце с коэффициентом оттока
users_count['churn_rate'] = (
    users_count['users'] / users_count['users']
    .shift(1, fill_value = users_count['users'].max()) * 100).round(2)
# исключаю данные с Tutorial
users_count.query('index != "Tutorial"', inplace=True)
users_count
Out[22]:
count_events users users_part churn_rate
event
MainScreenAppear 117328 7419 98.47 100.00
OffersScreenAppear 46333 4593 60.96 61.91
CartScreenAppear 42303 3734 49.56 81.30
PaymentScreenSuccessful 33918 3539 46.97 94.78
In [23]:
print('Доля пользователей доходящая от первого события до оплаты: {:.2%}'
      .format(users_count.loc['PaymentScreenSuccessful', 'users'] / users_count['users'].max()))
Доля пользователей доходящая от первого события до оплаты: 47.70%
In [24]:
# строю визуализацию
fig = go.Figure()
fig.add_trace(go.Funnel(
    name = 'users',
    orientation = 'h',
    y = users_count.index,
    x = users_count['users'],
    textinfo = 'value+percent initial'))
fig.update_layout(title='Воронка событий', title_x=0.5)
fig.show()

Вывод: \ По факту расчета видно сильное западение при переходе на второй этап - около 40%. Оставшиеся 2 шага показывают обратный результат: более 80% юзеров, открывших карточку товара, положили товар в карзину, и практически все из них (95%) совершили оплату за заказ, что перевело их в разряд покупателей. \ Общая конверсия составила 47.7%, что является отличным показателем.\ Визуализировал воронку для наглядного понимания оттока пользователей между событиями.

Изучение результатов эксперимента¶

А/A-тестирование¶

In [25]:
# создаю сводную таблицу с количеством уникальных пользователей
total_users = data_research.pivot_table(columns='group', values='user_id', aggfunc='nunique')
total_users
Out[25]:
group 246 247 248
user_id 2484 2513 2537
In [26]:
# подготавливаю датасет для визуализации
exp_group = (
    data_research
    .query('event != "Tutorial"')
    .pivot_table(index='event',
                 columns='group',
                 values='user_id',
                 aggfunc='nunique')
)
exp_group.sort_values(by='246',
                      ascending=False,
                      inplace=True)
# строю визуализации
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15,7))
palette = sns.color_palette('Set2')[0:4]
fig.suptitle('Количество пользователей по событиям в контрольных группах', size=18)
ax1.pie(x=exp_group['246'], labels = exp_group.index,
        autopct='%1.1f%%', colors = palette, normalize=True,
        wedgeprops={'lw':1, 'ls':'-','edgecolor':"k"},
        explode = (0.1, 0, 0, 0))
ax1.set_title('Группа 246', size=14)
ax2.pie(x=exp_group['247'], labels = exp_group.index,
        autopct='%1.1f%%', colors = palette, normalize=True,
        wedgeprops={'lw':1, 'ls':'-','edgecolor':"k"},
        explode = (0.1, 0, 0, 0))
ax2.set_title('Группа 247', size=14)
fig.tight_layout()

exp_group.plot(kind='pie', y='248', labels=exp_group.index,
        autopct='%1.1f%%', colors = palette, normalize=True,
        wedgeprops={'lw':1, 'ls':'-','edgecolor':"k"},
        explode = (0.1, 0, 0, 0), legend=False, figsize=(7, 7))
plt.ylabel('')
plt.title('Количество пользователей по событиям в экспериментальной группе - 248', size=18)
exp_group.T
Out[26]:
event MainScreenAppear OffersScreenAppear CartScreenAppear PaymentScreenSuccessful
group
246 2450 1542 1266 1200
247 2476 1520 1238 1158
248 2493 1531 1230 1181

Сделал расчет количества пользователей по контрольным (246 и 247) и экспериментальной (248) группам и в разбивке по событиям. Для наглядности визуализировал. По графикам видно, что по соотношению долей группы очень близки, но особо отмечается сходство группы 247 и 248: в большенстве событий идентичны.

Для удобства работы объединю показания таблицы с количеством уникальным пользователей и таблицы с разбивкой пользователей по событиям. Напишу функцию для расчёта статистически значимой разницы между долями двух генеральных совокупностей, так как она будет использоваться для каждого события последовательно.

In [27]:
# объединяю таблицы
total_users.reset_index(inplace=True)
total_users.columns = ['event', '246', '247', '248']
exp_users = pd.concat([total_users, exp_group.reset_index()], ignore_index=True)
exp_users
Out[27]:
event 246 247 248
0 user_id 2484 2513 2537
1 MainScreenAppear 2450 2476 2493
2 OffersScreenAppear 1542 1520 1531
3 CartScreenAppear 1266 1238 1230
4 PaymentScreenSuccessful 1200 1158 1181

Гипотезы: \ Нулевая гипотеза (H0): статистически значимые различия между долями двух генеральных совокупностей нет. \ Альтернативная гипотеза (H1): статистически значимые различия между долями двух генеральных совокупностей есть.

Параметры для проверки всех последующих гипотез: \ Статистическая значимость: (alpha) 0.01. Так как проводится A/A-тестирование, уровень значимости устанавлю на минимально возможном значении. \ Метод для проверки гипотез: Проверка гипотезы о равенстве долей - так как если некоторая доля генеральной совокупности обладает признаком, а другая её часть — нет, об этой доле можно судить по выборке из генеральной совокупности. Как и в случае со средним, выборочные доли будут нормально распределены вокруг настоящей. \ Поправка на множественную проверку гипотез: Метод Холма - он в среднем даёт более высокие значения уровня значимости, поскольку он более «щадящий» по отношению к мощности теста.

In [28]:
# применяю функцию для расчёта статистически значимой разницы в контрольных группах
stat_value(list(exp_users['246']), list(exp_users['247']), 16, 0.01)
Событие: MainScreenAppear
Значение p-value: 0.37853
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: OffersScreenAppear
Значение p-value: 0.13112
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: CartScreenAppear
Значение p-value: 0.31969
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: PaymentScreenSuccessful
Значение p-value: 0.09122
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Вывод: \ Гипотеза о равенстве пропорций двух контрольных групп для А/А-эксперимента не отверглась, статистическая разница между выборками 246 и 247 отсутствует во всех событиях и при всех уровнях статистической значимости с учетом корректировки Холма на множественную проверку гипотез.

A/B и A/A/B-тестирования¶

Поскольку две контрольные группы подходят для продолжения эксперимента, то целесообразно будет провести тестирование каждой контрольной группы с экспериментальной по отдельности, затем сравнить совокупное значение контрольных групп (246 и 247) с экспериментальной (248).

In [29]:
# добавляю в датасет столбец с совокупными данными контрольных групп
exp_users['246_247'] = exp_users['246'] + exp_users['247']
exp_users
Out[29]:
event 246 247 248 246_247
0 user_id 2484 2513 2537 4997
1 MainScreenAppear 2450 2476 2493 4926
2 OffersScreenAppear 1542 1520 1531 3062
3 CartScreenAppear 1266 1238 1230 2504
4 PaymentScreenSuccessful 1200 1158 1181 2358

Для трех последующих тестов будет применены следующие гипотезы и параментры.

Гипотезы: \ Нулевая гипотеза (H0): статистически значимые различия между долями двух генеральных совокупностей нет. \ Альтернативная гипотеза (H1): статистически значимые различия между долями двух генеральных совокупностей есть.

Параметры для проверки всех последующих гипотез: \ Статистическая значимость: (alpha) 0.05. \ Метод для проверки гипотез: Проверка гипотезы о равенстве долей. \ Поправка на множественную проверку гипотез: Метод Холма.

In [30]:
# применяю функцию для расчёта статистически значимой разницы
# в контрольной и экспериментальной группах
stat_value(list(exp_users['246']), list(exp_users['248']), 16, 0.05)
Событие: MainScreenAppear
Значение p-value: 0.14749
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: OffersScreenAppear
Значение p-value: 0.13421
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: CartScreenAppear
Значение p-value: 0.10561
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: PaymentScreenSuccessful
Значение p-value: 0.92852
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Гипотеза о равенстве пропорций двух групп для А/В-эксперимента не отверглась, статистическая разница между выборками 246 и 248 отсутствует во всех событиях и при всех уровнях статистической значимости с учетом корректировки Холма на множественную проверку гипотез.

In [31]:
# применяю функцию для расчёта статистически значимой разницы
# в контрольной и экспериментальной группах
stat_value(list(exp_users['247']), list(exp_users['248']), 16, 0.05)
Событие: MainScreenAppear
Значение p-value: 0.22935
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: OffersScreenAppear
Значение p-value: 0.50653
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: CartScreenAppear
Значение p-value: 0.21825
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: PaymentScreenSuccessful
Значение p-value: 0.99716
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Гипотеза о равенстве пропорций двух групп для А/В-эксперимента не отверглась, статистическая разница между выборками 247 и 248 отсутствует во всех событиях и при всех уровнях статистической значимости с учетом корректировки Холма на множественную проверку гипотез.

In [32]:
# применяю функцию для расчёта статистически значимой разницы
# в совокупности контрольных и экспериментальной группах
stat_value(list(exp_users['246_247']), list(exp_users['248']), 16, 0.05)
Событие: MainScreenAppear
Значение p-value: 0.14712
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: OffersScreenAppear
Значение p-value: 0.26543
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: CartScreenAppear
Значение p-value: 0.11953
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Событие: PaymentScreenSuccessful
Значение p-value: 0.99144
Доля отвергнутых нулевых гипотез 0.0% для 16 тестов с коррекцией Холма

Гипотеза о равенстве пропорций двух контрольных групп и экспериментальной для А/A/В-эксперимента не отверглась, статистическая разница между выборками 246-247 и 248 отсутствует во всех событиях и при всех уровнях статистической значимости с учетом корректировки Холма на множественную проверку гипотез.

Вывод: \ Так как была проведена множествыенная проверка гипотех, то для чистоты эксперимента применил корректировку Холма для уровня статистической значимости. В ходе проведения А/B и A/A/B-тестов, удалось выяснить, что между количеством пользователей, совершивших каждое событие, в контрольных и экспериментальной группах нет статистически значимой разницы, можно считать, что доля пользователей ,совершивших одно и тоже событие, одинакова для всех групп и экспериментах. \ Следовательно, можно сделать вывод, что новые шрифты, которые показывали пользователям экспериментальной группы (248) никак не повлияли на поведение пользователей внутри приложения.

Чтобы справиться с проблемой множественного сравнения гипотез, воспользовался дополнительно калькулятором Chi-Squared Test. По всем группам не выявлена статистически значимые различия.

Общие выводы¶

Для проведения тестирования был предоставлен датасет:

  • период с данными с 2019-07-25 по 2019-08-07;
  • датасет содержал 413 явных дубликатов, которые в результате предобработки данных были удалены;
  • количество уникальных пользователей 7551.

Проведены работы по изучению и проверке данных:

  • в среднем на одного пользователя приходится 32 события;
  • выявил отсутсвие полных данные в начале периода и скорректировали период: с 2019-08-01 по 2019-08-07;

Изучена воронка событий и её последовательность:

  • Определили количество и последовательность событий:
  • наиболее часто вызываемым событием является - MainScreenAppear - главный экран, данное событие было вызвано 117 328 раз;
  • наименее часто вызываемым событием является - Tutorial - инструкция по работе с приложением, данное событие было вызвано всего 1005 раз и для формирования воронки данные этого события не использовались;
  • в среднем 47.7% пользователей от общего количества пользователей совершают покупку и производят успешную оплату в приложении;
  • большее всего пользователей теряется на этапе OffersScreenAppear, данное событие совершает только 62% пользователей от общего количества пользователей, совершивших предыдущее событие. На остальных шагах воронки потери пользователей не такие серьёзные.

Результаты тестирований:

  • предварительно провел A/A-тест, чтобы убедиться, что количество пользователей в различных группах различается не более, чем на 1%; для всех групп фиксируют и отправляют в системы аналитики данные об одном и том же; различие ключевых метрик по группам не превышает 1% и не имеет статистической значимости; попавший в одну из групп посетитель остаётся в этой группе до конца теста. Гипотеза о равенстве пропорций двух контрольных групп для А/А-эксперимента не отверглась;
  • результаты А/B и A/A/B-тестирований показали, что новые шрифты, которые показывали пользователям из группы 248, никак не повлияли на поведение пользователей внутри приложения.

Рекомендации:

  • в процессе обработки данных половина временного отрезка была исключена из-за неполноты данных. Было бы полезно продлить проведение теста на аналогичный период - неделя, чтобы убедиться в чистоте эксперимента;
  • на этапе постановки задач не было предложено допустимой статзначимости. Было бы целесообразно понять, в рамках каких значений считать эксперимент успешным.